干货!混合式架构App当中的通信安全
本文字数:2369字
预计阅读时间:10分钟
作者介绍
本期特邀作者:
毕业于武汉大学,具有5年Android开发经验,在App通信安全、Android相关技术架构与选型方面有一定见解,擅长Flutter、Vue等跨端框架。
导读
本文主要介绍混合式架构App当中的通信安全。由于通信报文是不能保证不被截取的,所以本文主要的措施就是防破解、防篡改和防重放,以Android原生+Web混合式开发框架为例进行说明。
一、加密方案
App客户端在与服务器进行通信的过程中,数据有可能被中间人攻击。如果有一道加密算法做保证的话,能够减小中间人破解数据的可能性,或者说增大他破解数据的代价转而去攻击更容易的目标。但是如果仅仅只是普通的加密方案,那在此描述的价值就不是很大了。
大家都知道,对于Android apk,破解so库的难度要远远大于反编译Java代码。所以我们的第一想法是把通信过程中的加密算法,下沉到使用C/C++实现,然后编译成so库。Android应用在进行网络通信的时候,Java层代码通过JNI调用C/C++层的加密算法。
关于密钥生成则使用动态密钥的方式,通过增加一个可变因子,增加逆向难度和破解后的应对措施。
这里给出java层调用C/C++的native接口参考:
public class NewSign
{
/**
* 用过程密钥对data进行3DES加密,并返回加密后的密文数据
* @param signType 迷糊
* @param timeStam 迷糊
* @param random 迷糊
* @param data N字节明文数据
* @return 16字节随机数+"N字节明文数据"的密文
*/
public static native byte[] encodeData(int signType, long timeStam, long random, byte[] data);
/**
* 用过程密钥对data进行3DES解密,并返回解密后的密文数据
* @param signType 迷糊
* @param timeStam 迷糊
* @param random 迷糊
* @param data 16字节随机数+"N字节明文数据"的密文
* @return N字节明文数据
*/
public static native byte[] decodeData(int signType, long timeStam, long random, byte[] code);
static{
System.loadLibrary("newsign");
}
}
二、Web通信安全
在混合式架构App中,很多业务逻辑都是由Web开发完成的。Web不可避免地要与服务器进行频繁的网络通信。那Web请求是否也有必要实现一套相同的加密算法呢?
我们觉得没有必要:一方面是因为js实现的加密算法反破解能力还没有C/C++编译形成so库好;另一方面如前所述,Android原生已经实现过一套加密算法了,如果js再实现一遍,简直是重复开发。
那么我们的思路,是Web通信都由原生来进行转发,原生给Web提供安全的网络通信框架。而具体的JS层怎么调用原生的,本文不表,它是混合式开发框架App的基础。
这里给出网络转发的实现参考:
/**
* 为Web提供的网络转发方法
*
* @param type
* @param actionName
* @param url
* @param jsonStr
* @param callback
* @param showType
* 默认为0显示dialog,为1不显示dialog
*/
public void sendRequest(int type, final String actionName, String url, String jsonStr, final String callback, final int showType) {
TraceLogUtil.logCallWapJs("sendRequest", "type:" + type + "|actionName:" + actionName + "|url:" + url + "|jsonStr:" +
jsonStr + "|callback:" + callback + "|showType:" + showType, AppConfig.currentToken);
RequestListener requestListener = new RequestListener() {
public void onRequest() {
if (showType == 1) {
} else {
showDialog("正在处理,请稍后...");
}
}
public void onSuccess(String response, String url, int actionId) {
dismissDialog();
try {
JSONObject result = StringUtils.stringToJSONObject(response);
if (!AppUtils.isDataError(result, url, "Sencha Touch " + actionName)) {
final String json = result.optString("ACTION_INFO");
String deJson = SecurityUtil.decode(json);
JSONObject data = StringUtils.stringToJSONObject(deJson);
try {
result.put("ACTION_INFO", data);
} catch (JSONException e) {
e.printStackTrace();
}
response = result.toString();
openUrl("javascript:" + callback + "(" + response + ")");
} else {
final JSONObject root = new JSONObject();
try {
root.put("ACTION_RETURN_CODE", result.optString("ACTION_RETURN_CODE"));
root.put("ACTION_RETURN_MESSAGE", result.optString("ACTION_RETURN_MESSAGE"));
} catch (JSONException e) {
e.printStackTrace();
}
openUrl("javascript:" + callback + "(" + root.toString() + ")");
}
} catch (Exception e) {
e.printStackTrace();
final JSONObject root = new JSONObject();
try {
root.put("ACTION_RETURN_CODE", "000012");
root.put("ACTION_RETURN_MESSAGE", ResourceUtil.getAppStringById(NewWebViewHostActivity.this, "R.string.parse_network_result_error"));
} catch (JSONException e1) {
e1.printStackTrace();
}
openUrl("javascript:" + callback + "(" + root.toString() + ")");
}
}
public void onError(String errorMsg, String url, int actionId) {
dismissDialog(); showToast(ResourceUtil.getAppStringById(NewWebViewHostActivity.this, "R.string.network_error"));
final JSONObject root = new JSONObject();
try {
root.put("ACTION_RETURN_CODE", "000011");
root.put("ACTION_RETURN_MESSAGE", ResourceUtil.getAppStringById(NewWebViewHostActivity.this, "R.string.network_error"));
} catch (JSONException e) {
e.printStackTrace();
}
openUrl("javascript:" + callback + "(" + root.toString() + ")");
}
};
switch (type) {
case AppConstants.POST:
JSONObject jsonObject = DataToUtils.stringToJson(jsonStr);
String params0 = AppUtils.buildRequest(mContext, DataToUtils.jsonObjectToMap(jsonObject), actionName, true);
Map<String, String> headers = new HashMap<String, String>();
headers.put("Content-Type", "application/json");
mLoadControler = RequestManager.getInstance().request(mInstance, Method.POST, url, params0, headers, requestListener, false, 30000, 0, 0);
break;
case AppConstants.GET:
mLoadControler = RequestManager.getInstance().get(mInstance, url, requestListener, 1);
break;
case AppConstants.FILEUPLOAD:
RequestMap params2 = new RequestMap();
File uploadFile = new File(uploadFileName);
params2.put("uploadFile", uploadFile);
mLoadControler = RequestManager.getInstance().post(mInstance, url, params2, requestListener, 2);
break;
default:
break;
}
}
三、Web资源防破解
我们都知道,对于原生Android代码有混淆和加固两种常规的保护方式,默认读者已经会使用了。那么在混合式开发框架的App中,Web层资源如何保护呢?常规的压缩、混淆和加密这里不表,仅仅是Web层的资源包(包括html、appcache、javascript、json、图片等),如何防止被外界破解呢?
我们在将Web资源包下发的时候,并不是文件夹的形式,而是通过将其压缩成zip包,并设置密码。
在加载的时候,以流的形式读入HttpServer。除了随apk发版的Web资源包是一个约定的密码,后续的Hotfix形式的资源包都是动态密码下发,即密码在资源包之前下发。之所以不选择与资源包一起下发,是降低密码与资源包一起被截获的可能,虽然这里的密码传输也使用了前面所述的加密。
使用zip4j读取密码zip包参考:
public void initRootFile(ZipFile zf, String psw) throws IOException, ZipException{
// zf = new ZipFile(rootFile);
if(zf.isEncrypted()){
zf.setPassword(psw);
}
List<FileHeader> files=zf.getFileHeaders();
for(FileHeader fh:files){
System.out.println(fh.getFileName());
fileIndex.put(fh.getFileName(), fh);
zipFileMap.put(fh.getFileName(), zf);
}
}
HttpServer中的HttpStaticZipHandler实现参考:
public class HttpStaticZipHandler implements HttpRequestHandler {
private ZipVFS vfs=null;
/**
* Construct a new static file server
*
* @param documentRoot
* The document root
* @throws ZipException
* @throws IOException
*/
public HttpStaticZipHandler(String zipFile,String psw) throws IOException, ZipException {
vfs=ZipVFS.getInstance();
ZipFile zf = new ZipFile(zipFile);
vfs.initRootFile(zf, psw);
}
public HttpResponse handleRequest(HttpRequest request) {
String uri = request.getUri();
try {
uri = URLDecoder.decode(uri, "UTF-8");
} catch (UnsupportedEncodingException e1) {
uri = uri.replace("%20", " ");
}
ZipInputStream zis=null;
String path=uri.toString();
try {
if(path.startsWith("/")){
path=path.substring(1);
}
zis=vfs.getFileInputStream(path);
} catch (IOException e1) {
e1.printStackTrace();
return new HttpResponse(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.toString());
} catch (ZipException e1) {
e1.printStackTrace();
return new HttpResponse(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.toString());
}
if (zis!=null) {
try {
HttpResponse res = new HttpResponse(HttpStatus.OK, zis);
res.setResponseLength(vfs.getFileSize(path));
if (uri.endsWith(".css")) {
res.addHeader("Content-Type", "text/css");
}
res.addHeader("Access-Control-Allow-Origin", "*");
res.addHeader("Access-Control-Allow-Headers", "X-Requested-With");
res.addHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
return res;
} catch (IOException e) {
e.printStackTrace();
}
}
return null;
}
}
四、Https通信
大家都知道Https要比Http安全,现在几乎讲究一点的App通信都将Http切换到了Https上。
但是由于Android的共享证书机制,需要在应用里放一张与服务器对应的证书并进行校验。通过对网络请求框架(比如Volley)的改造,传入不同的证书rawId,可以达到多域名证书校验的效果(针对一个App需要请求不同业务后台的Https域名)。
public static RequestQueue newRequestQueue(Context context,
HttpStack stack, boolean selfSignedCertificate, int rawId) {
File cacheDir = new File(context.getCacheDir(), DEFAULT_CACHE_DIR);
String userAgent = "volley/0";
try {
String packageName = context.getPackageName();
PackageInfo info = context.getPackageManager().getPackageInfo(
packageName, 0);
userAgent = packageName + "/" + info.versionCode;
} catch (NameNotFoundException e) {
}
if (stack == null) {
if (Build.VERSION.SDK_INT >= 9) {
if (selfSignedCertificate) {
stack = new HurlStack(null, buildSSLSocketFactory(context,
rawId));
} else {
stack = new HurlStack();
}
} else {
// Prior to Gingerbread, HttpUrlConnection was unreliable.
// See:
// http://android-developers.blogspot.com/2011/09/androids-http-clients.html
if (selfSignedCertificate)
stack = new HttpClientStack(getHttpClient(context, rawId));
else {
stack = new HttpClientStack(
AndroidHttpClient.newInstance(userAgent));
}
}
}
Network network = new BasicNetwork(stack);
RequestQueue queue = new RequestQueue(new DiskBasedCache(cacheDir),
network);
queue.start();
return queue;
}
另外需要将域名证书强校验打开:
1protected HttpURLConnection createConnection(URL url) throws IOException {
2 //访问https,信任SSL开关
3 if (url.toString().toLowerCase(Locale.CHINA).startsWith("https")) {
4// HTTPSTrustManager.allowAllSSL();
5 //证书&域名强验证
6HttpsURLConnection.setDefaultHostnameVerifier(org.apache.http.conn.ssl.SSLSocketFactory.STRICT_HOSTNAME_VERIFIER);
7 }
8 return (HttpURLConnection) url.openConnection();
9 }
五、防篡改和防重放
那么如何去做呢?sessionId在登录的时候下发。要知道单纯地使用sessionId也就是token机制,并不能防篡改和防重放攻击。
这里我们对两个约定的参数(sessionId+时间戳)+密文再MD5,服务端通过相同方法比对。也就是常说的签名和解签机制。
客户端签名参考:
/**
* 请求报文的ACTION_TOKEN部分
* @param actionInfo
* @return
*/
public static JSONObject getSignToken(String actionInfo){
JSONObject signToken = new JSONObject();
String timeStamp = System.currentTimeMillis() + "";
addData(signToken, "USERID", PayCommonInfo.userId);
addData(signToken, "TIMESTAMP", timeStamp);
addData(signToken, "SIGN", AppUtils.encryptMD5(PayCommonInfo.sessionId + timeStamp + actionInfo));
return signToken;
}
服务端接到这个请求的处理逻辑:
先验证SIGN签名是否合理,证明请求参数没有被中途篡改,再验证这个MD5值是否已经有了,证明这个请求不是一段时间内(比如1个小时)的重放请求。
总结
我上面只列出了,当前混合式架构App中有关安全通信方面的一些关键技术点,特别是一二三四点具有一定的创新性。除与Web相关的技术点外,其它都可用于纯原生架构的App。当然呢,安全通信还有一些其它的小细节不详细罗列了。前述所有实践经过了行业多年的时间检验,这包括经多次专业机构(如安恒信息)的渗透测试与代码整改。
参考文章:
[1]https://mp.weixin.qq.com/s/1lOvKBjL2qlRlLHP4rHONg
[2]https://mp.weixin.qq.com/s/gKt-p1xutxl9KH9F9iBbMg
[3]https://www.cnblogs.com/lexiaofei/p/7297400.html
[4]《Android高级进阶》
也许你还想看
(▼点击文章标题或封面查看)
2018-08-30
2019-04-18
2018-08-16
加入搜狐技术作者天团
千元稿费等你来!
戳这里!☛